"""
Trial unittest with javascript and a DOM.
"""
from __future__ import with_statement

import os
from string import Template
import tempfile
import urlparse

from twisted.trial import unittest
from twisted.internet import defer
from twisted.python.filepath import FilePath

import simplejson

import gtk
import webkit
import javascriptcore as jscore


PATCH_ASYNC = ('static/assert.js', 'hermes/domtest/patch.async.js',)

LATEST_JQUERY = ("static/jquery151.all.js", )

BUILDER_SCRIPTS = LATEST_JQUERY + ( #{{{
    'static/base.js',
    'static/templates.js',
    'static/jstemplate.js',
    'static/sst.js',
    'static/lang.js',
    'static/dui.js',
    'static/assert.js',
    'static/helper.js',
    'static/conditions.js',
    'static/observer.js',
    'static/controller.js',
    'static/uniqueids.js',
    'static/projectSummary.js',
    'static/builder/whiteboard.js',
    'static/builder/multiselect.js',
    'static/builder/mls.js',
    'static/builder/xmleditor.js',
    'static/builder/values.js',
    'static/builder/options.js',
    'static/builder/rawoptions.js',
    'static/builder/markers.js',
    'static/jquery.FCKEditor.js',
    'static/builder/heartbeat.js',
    'static/builder/surveycontroller.js',
    'static/builder/stageview.js',
    'static/builder/treeview.js',
    'static/builder/build.js',
    'static/builder/wizard.js',
    'static/builder/fabric.js',
    'static/builder/store.js',
    'static/builder/archive.js',
    'static/builder/hatsful.js',
    'static/builder/stage.js',
    'static/builder/tree.js',
    'static/builder/commandValidation.js',
    'static/builder/conditiongrid.js',
    'static/builder/quota.js',
    'static/builder/rowcond.js',
    'static/builder/rte.js',
    'static/md5.js',
    'static/date.js',
    'static/tasks.js'
    
) + PATCH_ASYNC #}}}


class WebkitOpenError(Exception):
    """
    The webkit load_uri function was unable to open that URI
    """
    def __init__(self, uri):
        self.uri = uri
        
    def __str__(self):
        return "Could not open %r.  Webkit won't tell us why, either.  Good luck!" % (self.uri,)


class SourceDebugJSException(Exception):
    def __init__(self, jsexception, source=None):
        self.jsexception = jsexception
        self.source = source
        _exc = self.jsexception.pyWrapped
        self.sourceURL = None
        self.realMessage = None
        if isinstance(_exc, unicode):
            self.lineNo = 0
            self.realMessage = _exc
        else:
            if _exc.get('sourceURL'):
                # this exception occurred in another file
                self.sourceURL = _exc['sourceURL']
                parsed = urlparse.urlparse(self.sourceURL)
                self.source = FilePath(parsed[2]).getContent()
            self.lineNo = _exc['line'] - 1

    def __str__(self):
        if self.source is not None:
            lines = self.source.splitlines()
            line = lines[self.lineNo]
            preceding = lines[:self.lineNo]
        else:
            line = ''
            preceding = ['']

        _exc = self.jsexception.pyWrapped
        if 'expressionBeginOffset' in _exc:
            offset = _exc['expressionBeginOffset']
            offsetToLine = sum(len(x) for x in preceding) + len(preceding)
            lineOffset = offset - offsetToLine
            where = '  <Javascript>, line %s, character %s' % (
                    _exc['line'], lineOffset, )
            if self.sourceURL:
                where = where + ' in %s' % (self.sourceURL,)
            s = [self.jsexception.name + ': ' + str(self.jsexception),
                 where,
                 '    ' + line,
                 '    ' + ' ' * lineOffset + '^',
                 ]
        else:
            # probably a javascript SyntaxError, or a thrown string
            where = '  Javascript, line %s' % ( self.lineNo, )
            if self.sourceURL:
                where = where + ' in %s' % (self.sourceURL,)
            if self.realMessage:
                description = self.realMessage
            else:
                description = str(self.jsexception)
            s = [self.jsexception.name + ': ' + description,
                 where,
                 line,
                 ]
        return '\n'.join(s)


class DOMTestCase(unittest.TestCase):
    """
    Testcase frame that simplifies inspection of JS code effects
    """
    def setUp(self):
        """
        Fresh client and alert set for each test.
        """
        self.toJSON = None
        self.alerts = []
        self.view = webkit.WebView()
        self.context = None

    def openURL(self, url):
        return self._open(url, self.view.load_uri)

    def openString(self, markup, contentType='text/html',
            contentEncoding='utf-8', base='about:dom-test-case'):
        openFn = lambda s: self.view.load_string(s, contentType,
                contentEncoding, base)
        return self._open(markup, openFn)

    def _open(self, urlOrString, openFn):
        """
        Open the URL asynchronously and return a deferred which is called with
        a tuple of the view (aka self.view) and the frame (opened page)
        """
        assert self.context is None, (
                "Opening two different URLs in the same test is not supported yet"
                )
        openFn(urlOrString)
        d = defer.Deferred()
        
        def _onLoadFinished(v, f, url):
            # uri set to None: means an error loading the page.  We need to
            # raise our own exception, though, because the load-error signal
            # used by webkit provides a useless opaque GPointer where the
            # exception should be.
            if f.props.uri is None:
                try:
                    raise WebkitOpenError(url)
                except WebkitOpenError, e:
                    import sys
                    cls, val, tb = sys.exc_info()
                    from twisted.python.failure import Failure
                    fl = Failure(e, cls, tb)

                    if not d.called:
                        # we choose to IGNORE exceptions following the first
                        # success-or-failure.  We operate on the theory that
                        # these are iframes that could not be opened (and did
                        # not need to be opened).
                        d.errback(fl)
            else:
                # load-finished can be emitted for iframes, which usually have
                # about:blank as the url.  we can ignore those for now..
                uri = f.props.uri
                if uri != urlOrString and uri != 'about:dom-test-case':
                    return

                d.callback((v,f)) 

        self.view.connect('load-finished',  
                lambda v, f: _onLoadFinished(v, f, urlOrString))


        # Intercept alerts 

        # TODO: make this behavior configurable so you CAN get the popup
        # TODO: do prompt and confirm with script-prompt/script-confirm
        # TODO: do onbeforeunload.. is that close-web-view?
        def _onAlert(view, frame, *a):
            self.alerts.append(a[0])
            return True

        self.view.connect('script-alert', _onAlert)


        ### XXX - this signal doesn't seem to work in webkit1.2.5 (message is
        ###     always a blank string) - try with a 1.3.x version maybe.
        ### def _onConsoleMessage(view, frame, message, line, sourceID, userData):
        ###     self.alerts.append(message)
        ###     return True
        ### self.view.connect('console-message', _onConsoleMessage)


        def _opened((view, frame)):
            self.context = jscore.JSContext(frame.get_global_context())
            return (view, frame)
        
        d.addCallback(_opened)

        return d

    def tearDown(self):
        """
        Free up long-lived resources to try not to leak memory.  This needs a
        lot more work done: FIXME
        """
        if self.context:
            # TODO - we still leak memory, but this helps a LOT.
            self.rawJS('window.close()')

    def assertAlerts(self, expected):
        """
        Verify the alerts we got against the expected string.  Pass in the
        frame which was expected to have generated the alerts you are checking, 
        and a list of alerts.
        """
        self.assertEqual(self.alerts, expected)

    def assertRaisesJS(self, context, exceptionName, testscript, *args):
        """
        Verify that given Javascript code throws expected exceptions
        """
        try:
            testscript = self.interpolateJSON(testscript, *args)
            self.rawJS(testscript)
            raise AssertionError("%s not raised" % exceptionName)
        except SourceDebugJSException, scriptEx:
            jsEx = scriptEx.jsexception.pyWrapped

            if isinstance(jsEx, unicode):
                self.assertEqual(jsEx, exceptionName)
            elif jsEx.get('name'):
                self.assertEqual(jsEx['name'], exceptionName)
            else:
                self.fail("weirdo exception doesn't have a name and isn't a string")

    def pageSource(self):
        """
        Dump the page source for this page
        """
        return self.rawJS('document.documentElement.outerHTML')

    def showWindow(self): # pragma: nocover
        """
        Pop up the actual browser window; with 'inspector' flag, also show
        the inspector window, and wait for it to close.  Returning this from a
        test causes the test to fail but gives you the opportunity to debug
        it.
        """
        settings = self.view.get_settings()
        settings.set_property("enable-developer-extras", True)

        self.win = gtk.Window()
        self.win.add(self.view)
        self.win.show_all()

        self.inspector = self.view.get_web_inspector()
        def _onInspectWebView(inspector, view):
            win2 = gtk.Window()
            view2 = webkit.WebView()
            win2.add(view2)
            win2.show_all()
            return view2
        self.inspector.connect('inspect-web-view', _onInspectWebView)

        d = defer.Deferred()
        self.win.connect('delete-event', lambda *a: d.callback(a))

        def _failNow(_ignored):
            assert 0, "Debug window was shown--test fails."

        d.addCallback(_failNow)
        return d

    @staticmethod
    def wrapJSCode(context, code):
        """
        Run some javascript in the view's context, return result.  Handle
        exceptions to present a nicer traceback.
        
        Any string literal passed in that has newlines '\n' must be quoted
        with r"" so that python does not interpret the newline 
        """
        try:
            res = context.evaluateScript(code)
        except jscore.JSException, e:
            newExc = SourceDebugJSException(e, code)
            raise newExc
        return res

    @staticmethod
    def wrapJSFunction(fn, *args):
        """
        Run a javascript function, return the result. Handle exceptions to
        present a nicer traceback.
        """
        try:
            res = fn(*args)
        except jscore.JSException, e:
            newExc = SourceDebugJSException(e, 
                    'JSFunction call with args: %r' % (args,))
            raise newExc
        return res

    def rawJS(self, fmt, *args, **kwargs):
        """Run some javascript in self.context.

        Additional arguments will be converted with simplejson and
        interpolated in to the format string.

        Returns the result of the javascript expression.
        """
        script = self.interpolateJSON(fmt, *args, **kwargs)
        return self.wrapJSCode(self.context, script)

    def js(self, fmt, *args, **kwargs):
        """
        Like self.js, but assumes return val is a json string, and loads it
        """
        script = self.interpolateJSON(fmt, *args, **kwargs)
        o = self.wrapJSCode(self.context, script)
        if self.toJSON is None:
            self.toJSON = self.wrapJSCode(self.context, 
                """(function (ob) { 
                    if ( ob === undefined ) { 
                        return ob;
                    }
                    if ( window.$ !== undefined && $.toJSON !== undefined ) { 
                        s = $.toJSON(ob); 
                        return s;
                    } else {
                        throw "$.toJSON must be defined; use rawJS or make sure jquery.tojson.js is loaded.";
                    }
                });""")
        s = self.wrapJSFunction(self.toJSON, o)
        if not isinstance(s, basestring):
            return s
        return simplejson.loads(s)

    def interpolateJSON(self, fmt, *args, **kwargs):
        """Convert arguments with JSON and interpolate
        into the format string."""
        if args and kwargs:
            raise ValueError("Supply positional *or* named args, not both.")
        if args:
            args = [simplejson.dumps(o) for o in args]
            script = fmt % tuple(args)
        elif kwargs:
            d = {}
            for k, v in kwargs.items():
                d[k] = simplejson.dumps(v)
            script = fmt % d
        else:
            script = fmt
        return script


def scriptTag(src=None, code=None):
    tplSrc = Template(u"<script src='file://$src' type='text/javascript'></script>")
    tplNoSrc = Template(u"<script type='text/javascript'>$code</script>")

    assert src or code, 'src or code must be given as arguments'
    if src:
        data = tplSrc.substitute(src=src)
    elif code:
        data = tplNoSrc.substitute(code=code)
    return data


class BlankPageTestCase(DOMTestCase):
    """
    Testcase that runs all tests inside an about:blank frame for testing
    javascript that does not require an external frame or any interactions
    between two frames

    Most tests ported from crosscheck should use this.

    Set the class variable 'scripts' if you want to automatically load any
    javascript.
    
    Scripts is a tuple of 1. strings or 2. callable functions.

    1. If an item in scripts is a string, it is the filename of a javascript
    file to load.  That file will be inserted using a <script src> tag.

    2. If an item in scripts is a callable, it should be a callable with no
    arguments that returns a string.  That string will be inserted as
    javascript source code, inside a <script> tag with no src attribute.
    """
    scripts = ()
    frame = None
    blank = Template(open(os.path.dirname(__file__) + '/blank.html').read())
    
    def setUp(self):
        """
        This test case is assumed to have only one frame, which is a container
        for scripts
        """
        DOMTestCase.setUp(self)
        return self.setBlankPage()
        
    def setBlankPage(self):
        """
        Create a blank page with the default script tags loaded and no other markup.
        Used as a clean slate container for most tests.
        """
        # case 6014: mocked the console object and make it part of BlankPageTestCase, so all
        # unit tests get it
        self.scripts = tuple(self.scripts)

        fn = tempfile.mkstemp('.html')[1]
        scripts = []
        for sx in self.scripts:
            if callable(sx):
                code = sx()
                scripts.append(scriptTag(code=code))
            else:
                scripts.append(scriptTag(src=os.path.abspath(sx)))

        f = open(fn, 'w')
        f.write(self.blank.substitute( scripts='\n'.join(scripts),))
        f.close()

        def doneOpening((view,frame)):
            self.js("""(function () { var _bs = document.body.style;
                       _bs.setProperty('width', '1024px');
                       _bs.setProperty('height', '768px');})();""")
            self.addCleanup(FilePath(fn).remove)
            return self

        return self.openURL("file://%s" % (fn,)).addCallback(doneOpening)

    def isVisible(self, selector):
        """
        True if the selector is not display none and has height>0 and width>0

        This replaces the :visible selector in jquery
        """
        fn = self.rawJS('''(function (x) {
            var $x = $(x);
            var pHasArea=false, parentsDisplayed=false, displayed=false, w, h;
            if ($x.length == 0) {
                return false;
            }
            if ($x.is('input') && $x.attr('type') == 'hidden') {
                return false;
            }

            displayed = $x.css('display') !== 'none';
            if (!displayed) {
                return false;
            }

            parentsDisplayed = $x.parents().filter(function (index) {
                return $(this).css('display') === 'none';
            }).length == 0;
            if (!parentsDisplayed) {
                return false;
            }

            w = $x.css('width').toString();
            h = $x.css('height').toString();

            pHasArea = (w !== '0px' && h !== '0px');
            return pHasArea;
            });
            ''')
        return fn(selector)


